Skip to content

release: v1.4.10 — LTC portfolio stability, UTXO audit recovery, XRP REST signing, Hive onboarding#327

Merged
BitHighlander merged 60 commits into
masterfrom
release/1.4.10
Jul 3, 2026
Merged

release: v1.4.10 — LTC portfolio stability, UTXO audit recovery, XRP REST signing, Hive onboarding#327
BitHighlander merged 60 commits into
masterfrom
release/1.4.10

Conversation

@BitHighlander

Copy link
Copy Markdown
Collaborator

v1.4.10 — 37 commits since v1.4.9

Portfolio & balances

Audit / recovery

Signing

Hive

UX / onboarding

Windows

Zcash

Publish checklist

🤖 Generated with Claude Code

BitHighlander and others added 30 commits June 20, 2026 20:17
v1.4.6 published as prerelease.
applyPolicy raised the device's ENABLE/DISABLE POLICY confirm but bypassed
emuSigningOp, so on the emulator no interactive button was rendered and the
op hung in confirm_helper until the 120s timeout. Route it through
emuSigningOp when engine.isEmulator, matching every other device-confirm op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TL, 600s timeout

Vault-side fixes for the BEX 're-approve every time' bug (handoff from
keepkey-client PR #85). Four defects in the pairing store:

1. Idempotent pairing: approvePairing()/pair() now reuse a live key for the
   same identity instead of minting a new UUID + row every time. Identity is a
   client-supplied clientId (new, optional) or the (name,url,imageUrl) triple.
   reloadPairings() collapses pre-existing duplicate rows on load. POST
   /auth/pair returns { apiKey, reused }.
2. LRU eviction: evictIfFull() drops the least-recently-used key, not the
   oldest-inserted — so the actively-used key is never evicted at capacity.
3. Sliding TTL: validate() measures the 30d TTL against last use and refreshes
   recency (persist throttled to ~1x/hour). Active keys never expire; abandoned
   ones still age out.
4. Timeout: pending-pair auto-reject raised 60s -> 600s to match the SDK's
   POST /auth/pair wait, so a slow approval isn't discarded.

Also: coalesce concurrent same-identity pair requests onto one prompt (no 429);
DELETE /auth/pair revokes the caller's own key; nullable client_id + last_used_on
columns on paired_apps (backward-compatible migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix: emulator policy confirm button + pairing re-approve churn
…nloading…" (#284)

* fix(update): route updates to keepkey.com/update page, drop in-app "Downloading…"

Desktop update on macOS/Windows opened the GitHub releases page and the UI
optimistically showed a "Downloading…" progress bar even though nothing
downloads in-app. Now:

- Open https://keepkey.com/update?os=<mac|windows|linux>&arch=<arm64|x64>
  &version=<latest>&current=<installed> so the page can serve the correct
  build for the user's OS/arch and guide them through updating.
- Remove the optimistic 'downloading' phase in useUpdateState. Linux's native
  updater still drives real downloading/ready phases via status events; the
  open-the-page flow no longer shows a fake progress bar.
- Relabel the error-fallback button "Download from GitHub" -> "How to Update".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(update): quote URL for Windows cmd start; translate 'How to Update' in all locales

- Windows: '&' in the query string is a cmd separator; quote the URL so 'start'
  opens the full keepkey.com/update link instead of splitting at the first '&'.
- Update downloadManually copy in all 14 non-English locales (was stale
  'Download from GitHub' while the button now opens the update page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#285)

* fix(watch-only): make Refresh re-fetch balances from cached addresses

Watch-only mode (viewing a cached device snapshot with no device connected)
showed a Refresh button that did nothing: Dashboard.refreshBalances early-
returned on `watchOnly`, and getWatchOnlyBalances only read the DB cache.

Add a refreshWatchOnlyBalances RPC that reconstructs the pubkey list from
cache (cached chain addresses + cached BTC xpubs), re-queries Pioneer's
GetPortfolioBalances (no device), parses natives + tokens + DeFi into
ChainBalance[], persists, and returns. The device getBalances path and its
managers are untouched — this is a self-contained, display-only parse (no
per-owner EVM token map, no addressbook sync).

Frontend refreshBalances now runs this path in watch-only mode with proper
loading state and error surfacing instead of returning silently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(watch-only): don't overwrite cached balances with confirmed zeros on partial Pioneer failure

Track which chunks failed and exclude their chains from the confirmed set so
setCachedBalances uses the guarded upsert (keeps existing non-zero) instead of
forcing a 0 — matching the device getBalances behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nnected' (#286)

* fix(watch-only): show cached receive address instead of 'No device connected'

In watch-only mode there is no connected device, so every *GetAddress RPC
threw 'No device connected' and the Receive page rendered that error for
show-address, QR, and copy — even though the address is cached from the last
connection.

- AssetPage.deriveAddress and the BTC account-aware effect now short-circuit
  in watch-only and display the cached address (balance.address) instead of
  calling the device RPC.
- ReceiveView gains a watchOnly prop: hides Verify-on-Device, the BTC
  receive/change + index stepper, the TON bounceable toggle, and the edit-path
  pencil — all device-derive actions that can't work offline and would show a
  stale cached address. Path text stays visible (informational).
- Empty offline state shows a plain explanation instead of device-hitting
  retry/derive buttons.

Connected-device flow is unchanged (all new branches gate on watchOnly).

* fix(watch-only): stop EVM effect clearing cached address; hide account selectors

- EVM address effect now returns early in watch-only instead of setAddress(null)
  when the device-backed evmAddresses hook is empty (which it is offline),
  preserving balance.address as the watch-only source of truth.
- BtcXpubSelector / EvmAddressSelector are gated behind !watchOnly — their
  select/add/remove handlers call device/backend account RPCs and could mix
  live account state with the cached receive address.

Addresses PR #286 review findings 1 (high) and 2 (medium).
…rong seed (#287)

The emulator flash is not a trustworthy seed store — the firmware's storage
wrapping key changes per kkemu_init (it can't decrypt its own sec section
across restarts), so a persisted flash can boot a DIFFERENT seed than the one
the user saved as a mnemonic. The connect smoke-test passes on any valid seed,
so the wrong wallet was presented as ready with no warning.

- Deterministic seed-identity guard: compare the live m/44'/0'/0' BTC xpub
  (already fetched by the smoke-test) against the xpub derived offline from the
  saved mnemonic; on mismatch, wipe the flash and reload the saved seed.
- Blank-flash recovery: if the flash boots uninitialized but a mnemonic is
  saved for the wallet, restore the saved seed instead of dropping into setup.
- reloadSavedSeed verifies the live xpub after reload and errors if it still
  does not match — no path reports success without proving the seed.
- Consolidated the storage-stale recovery through reloadSavedSeed, replacing
  the duplicated inline reload + flaky DebugLink mnemonic verify.
v1.4.7 published as prerelease.
…t ship (#290)

The proto-tx-builder / hdwallet / device-protocol dist/ output is gitignored and
copied into the app bundle via file: refs. Their build stamps track src file
mtimes, but a submodule `git checkout` to a new pin does not reliably bump src
mtimes past an existing stamp — so make considers the build current and skips it,
shipping a STALE dist from a previous commit.

This shipped a pre-fix proto-tx-builder in v1.4.6 and v1.4.7: the source had the
@cosmjs/stargate Freegrant/Feegrant typo fallback (commit f12f8c3, which both
releases pinned), but the bundled dist predated it, so every Cosmos transaction
crashed with 'createFeegrantAminoConverters is not a function' (send, delegate,
undelegate).

Clear the module build stamps in build-signed (same pattern already used for the
zcash-cli stamp) so the release always rebuilds modules from the pinned source
before the vault install copies their dist into the bundle.
…d string (#289)

The swap dialog header rendered the literal 'Swaps.pro' (an unrelated
third-party brand) instead of the localized title. de610e8 replaced the
t('title') i18n key with a hardcoded string inside a UI styling refactor;
this also broke localization of the title in all languages. Revert to
t('title') (= 'Swap').
v1.4.8 published as prerelease.
….16→7.15

Add a user-facing `hiveEnabled` toggle to the Settings drawer, default OFF,
mirroring the zcashPrivacyEnabled pattern. Hive is hidden from the Dashboard
grid, command palette, and all backend chain lists unless the flag is on.

Also corrects the Hive firmware gate from 7.16.0 to 7.15.0. The Hive handler
ships in firmware 7.15.0 (alpha) — same release as Zcash — but the gate was
set to 7.16.0 (the device-protocol package version, not the firmware version),
which no firmware reports, so Hive could never appear even on supported devices.
The new default-off settings flag now carries "is Hive ready for users" instead.

- shared/types.ts, rpc-schema.ts: hiveEnabled on AppSettings + setHiveEnabled RPC
- bun/index.ts: persist + getAppSettings + handler (fw-gated 7.15.0) + 4 chain-filter sites
- chains.ts: minFirmware 7.16.0 → 7.15.0
- DeviceSettingsDrawer/Dashboard/CommandPalette/App: toggle UI + flag plumbing
…scan

Addresses review findings — the settings flag gated UI display sites but
several backend data paths still served Hive on firmware alone:

- REST /api/health no longer advertises Hive, and /addresses/hive returns 403
  when hive_enabled=0 (was firmware-only) — matches the /api/zcash gate.
- getCachedBalances drops stale Hive rows when the flag is off, so disabling
  Hive after enabling it no longer leaks cached balances into portfolio totals.
- Dashboard total (cleanBalanceUsd) skips Hive when off so stale in-memory rows
  from a live-refresh merge can't be counted while hidden from the grid; added
  an auto-refresh-on-enable effect mirroring Zcash.
- Background + single-chain activity scans filter Hive when off (was deriving
  on any firmware-eligible device).
- CommandPalette results memo now depends on firmwareVersion (was stale).
- types.ts comment corrected 7.16.0 → 7.15.0.
feat(hive): settings feature flag (default off) + fix firmware gate 7.16→7.15
…g plan

Vault derived Hive keys at m/44'/1275'/0'/0/0 (BIP-44) while firmware 7.15.0,
Ledger, and Hive Keychain all use SLIP-0048 m/48'/13'/role'/account'/key'. The
old path produced a STM key no other Hive tool derives from the same seed —
non-interoperable, and Pioneer's pubkey→account lookup could never match an
account made elsewhere. Safe breaking change: Hive is flag-gated default-off
(PR #293), so zero accounts exist on v1 keys.

- chains.ts: hive defaultPath → m/48'/13'/1'/0'/0' (active role)
- chains.ts: add hiveRolePath(role, accountIndex) SLIP-0048 helper for the
  upcoming multi-key / account-creation work
- docs/HIVE-ONBOARDING-PLAN.md: phased next-steps for self-run sponsor onboarding
  (decisions: KeepKey-owners-only gating, queue+circuit-breaker, single account,
  active-key only)

GATE before flag-on: verify the derived STM active key matches Hive Keychain
output for the same device seed.
Byte-exact spec derived from firmware 7.15.0 hive.c: the digest the device
signs (SHA256(chain_id||serialized_tx)), 65-byte sig recovery, account_create
op-9 serialization layout, and Pioneer's verification algorithm. Clears the
'blocked on firmware attestation-digest' status — no firmware change needed;
device returns serialized_tx, Pioneer verifies + parses the returned bytes.
…tAminoConverters is not a function') (#295)

The Windows production build's 'Building proto-tx-builder' step only ran
`bun install` — it never built the dist (tsc). proto-tx-builder/dist is
gitignored, so Windows shipped whatever stale pre-pin dist was on the box.
v1.4.6/1.4.7/1.4.8 thus shipped a proto-tx-builder compiled BEFORE the
@cosmjs/stargate Freegrant/Feegrant typo shim (f12f8c3), so every Cosmos
send/delegate crashed with 'createFeegrantAminoConverters is not a function'.

macOS was fixed in #290 (clear module stamps in build-signed); the Windows
pipeline is separate and was never patched. Add `bun run build` after the
install (mirrors the hdwallet yarn build right below) so Windows rebuilds the
dist from the pinned source.

Release hotfix — not RUJI-related; fast-track / cherry-pick to a release branch.
* test(thorchain): RUJI/TCY/secured-asset custom-denom signing + hdwallet bump

Adds a keepkey-sdk regression test exercising THORChain MsgSend with
arbitrary denoms (TCY, RUJI/Rujira, secured assets like btc-btc) and
MsgDeposit with memo against a running vault. Bumps the hdwallet
submodule to the matching allow-any-denom MsgSend fix.

* chore(hdwallet): pin to merged develop (allow-any-denom MsgSend)

* feat(thorchain): send + display THORChain bank tokens (TCY, RUJI, secured assets)

Threads a per-asset bank denom through the cosmos tx builder so a
THORChain MsgSend can move TCY (denom 'tcy'), RUJI ('x/ruji') or secured
assets — the fee stays in RUNE. Denom comes from a '/bank:<denom>' caip
segment, parsed greedily so denoms containing '/' survive. Token MAX
sends the full token balance (native fee is a separate RUNE balance, not
reserved against the token).

Display: classify '/bank:' caips as tokens so they group under THORChain
like ERC-20s. Unit test covers RUNE-native regression + TCY/RUJI/MAX.

Caip contract with Pioneer: cosmos:thorchain-mainnet-v1/bank:<denom>.
Surfacing balances still requires Pioneer to enumerate bank balances in
the portfolio (currently native-only).

* fix(emulator): stop WKWebView SIGTRAP crash on confirm into a not-ready webview

sendToWindow injected `window.handlePacket(...)` via executeJavascript; if
the webview's page hadn't loaded handlePacket yet, the JS error crashed the
WKWebView process with EXC_BREAKPOINT (SIGTRAP, exit 133) and took down the
app. viewReady gated this but was only reset on window close — a stale
`true` (e.g. webview rebuilt without a close event, as in an ethSignMessage
confirm right after reopen) let the inject fire into a fresh, unloaded
webview.

- Guard the injected JS: `if(window.handlePacket)...` — a not-ready inject
  is now a no-op, never a WebKit crash.
- Reset viewReady=false at openEmulatorWindow() so a reopen can't inherit a
  stale-ready gate.

* fix(thorchain): match Pioneer '/denom:' caip scheme + un-flag TCY/RUJI as spam

Pioneer emits bank tokens as cosmos:thorchain-mainnet-v1/denom:<denom>
(e.g. denom:tcy, denom:x/ruji), not /bank:. Align the send denom
extraction and the display token-denom regex to /denom: (bank: kept for
forward-compat). Allowlist THORChain/Maya /denom: tokens in the spam
filter — they're protocol-defined (no counterfeits possible) and not yet
in the discovery catalog, so TCY/RUJI were wrongly flagged SCAM.

* feat(swap): make THORChain TCY and RUJI tradable in the swapper

THOR.TCY and THOR.RUJI have live THORChain pools but weren't selectable.
- getSwapAssets: defensive shim adds THOR.TCY/THOR.RUJI (keyed by their
  /denom: caip) if Pioneer's available-assets omits them, mirroring the
  existing RUNE/TRON shims — so they appear in the picker under THORChain.
- assessAvailability: allowlist the two pooled /denom: caips as swappable
  via thorchain. Scoped to TCY/RUJI only — other non-pooled thorchain
  denom tokens correctly stay unsupported. Quotes route by caip through
  Pioneer.Quote (THOR.TCY/THOR.RUJI pools verified on thornode).

* fix(thorchain): send/swap TCY & RUJI correctly (denom: token + MsgDeposit)

Two confirmed bugs sending/swapping THORChain bank tokens:

1. SendForm.isTokenSend didn't recognize the /denom: namespace, so picking
   TCY left token-mode off: the amount showed RUNE and the caip never
   reached the builder — it built a RUNE send. Add /denom: to the check.

2. Swapping FROM TCY/RUJI failed 'Missing inbound vault address from quote'.
   These are on-chain bank tokens: Pioneer's quote returns txs[0].type=
   'deposit' with no inbound address (MsgDeposit). isNativeDepositCaip only
   matched RUNE/CACAO slip44 — extend it to thorchain/maya /denom: caips.
   The MsgDeposit coins asset was also hardcoded THOR.RUNE; thread the real
   asset (THOR.TCY/THOR.RUJI) via a new depositAsset param so the network
   takes the right coin.

cosmos.test.ts: +MsgDeposit case (asset=THOR.TCY, swap memo). 10/10.

* fix(swap): TCY/RUJI quote parser threw 'missing inbound address' too

The quote parser (swap-parsing.ts) had its OWN isNativeDeposit check
matching only RUNE/CACAO slip44 — it threw before the build path's check
(already fixed) ever ran, so from-TCY/RUJI swaps still failed.

Dedupe: single exported isNativeDepositCaip in swap-parsing.ts (covers
RUNE/CACAO + thorchain/maya /denom: bank tokens), imported by swap.ts.
One source of truth so the parser and builder can't disagree again.
Removes now-orphaned RUNE_CAIP/CACAO_CAIP constants.

* fix(swap+emulator): correct deposit preview asset + don't reject confirm before webview ready

- swap preview built the MsgDeposit with the default THOR.RUNE instead of
  THOR.TCY/THOR.RUJI (depositAsset wasn't threaded on the preview path, only
  execute). Preview now shows the real coin.
- Emulator confirm waited only 5s for a freshly-opened webview to post
  /_emu/ready, then auto-rejected ('Transaction rejected by user'). A cold
  webview on make dev exceeds that. Bump to 20s (loop still exits the instant
  viewReady flips, so it only raises the ceiling) so a swap confirm isn't
  spuriously rejected before the window is up.

* fix(windows-build): build proto-tx-builder dist (fixes 'createFeegrantAminoConverters is not a function')

The Windows production build's 'Building proto-tx-builder' step only ran
`bun install` — it never built the dist (tsc). proto-tx-builder/dist is
gitignored, so Windows shipped whatever stale pre-pin dist was on the box.
v1.4.6/1.4.7/1.4.8 thus shipped a proto-tx-builder compiled BEFORE the
@cosmjs/stargate Freegrant/Feegrant typo shim (f12f8c3), so every Cosmos
send/delegate crashed with 'createFeegrantAminoConverters is not a function'.

macOS was fixed in #290 (clear module stamps in build-signed); the Windows
pipeline is separate and was never patched. Add `bun run build` after the
install (mirrors the hdwallet yarn build right below) so Windows rebuilds the
dist from the pinned source.

Release hotfix — not RUJI-related; fast-track / cherry-pick to a release branch.

* feat(thorchain): gate TCY/RUJI behind firmware 7.15 (fund safety)

A bank-token MsgSend's denom field is honored only from firmware 7.15.0;
on older firmware it's ignored and the tx signs as RUNE — the user would
move RUNE thinking they moved TCY. Gate the whole TCY/RUJI feature on 7.15
using the existing minFirmware/assessWithFirmware pattern, extended to the
asset level (RUNE itself stays usable on older fw).

Single shared helper thorchainBankTokenFirmwareOK() in swap-support-matrix,
enforced at three layers:
- assessWithFirmware: dims TCY/RUJI in the swap picker with an upgrade hint
- headlessSwapQuote: refuses a quote when selling a bank token on old fw
- buildTx handler: backend fund-safety net — refuses to build the send/swap
  tx, so a bad asset can never reach signing

11/11 gate tests; assessWithFirmware verified (7.14→unsupported, 7.15→ok).

* fix(hdwallet): pin to e39ff8cb (master + RUJI denom fix), not the develop squash

PR review caught that the prior pin 8c8e5b01 was a develop squash-merge that
does NOT have c5a4d79b (hdwallet master) as an ancestor — it dropped master-only
work (WebUSB clearHalt, Zcash, TON/TRON, BIP-85, message-type registry, EVM
clear-signing). e39ff8cb is c5a4d79b + ONLY the denom fix, so the bump is now a
fast-forward that preserves all master history.

* fix(swap): TCY/RUJI token identity + enforce firmware gate at execute time

Two PR-review release blockers:

- Token identity (was falling through to RUNE balance): the getSwapAssets
  shim injected THOR.TCY/THOR.RUJI without a contractAddress, so SwapDialog's
  contractAddress-keyed token lookup missed them and used the native RUNE
  balance for max/validation/price. Set contractAddress to the bank denom
  ('tcy' / 'x/ruji') — matches the portfolio token's contractAddress (the
  denom, set by getBalances), so balance now resolves to the actual token.

- Execute-time firmware gate: the 7.15 bank-token gate was quote-only, but
  /api/v2/swap/execute (rest-swap.ts) calls headlessExecuteSwap directly,
  bypassing the quote check. A stale/crafted execute payload could reach
  signing on old firmware that signs the wrong asset. Re-check the invariant
  at execute time too (mirrors headlessSwapQuote + buildTx).

* chore(deps): bump pioneer-discovery 10.0.9 → 10.3.1 (RUJI/TCY caips + icons)

10.3.1 carries the correct THORChain bank-token caips
(cosmos:thorchain-mainnet-v1/denom:{tcy,x/ruji}) WITH icons and zero stale
thorchain-1/slip44:ruji — so RUJI/TCY now resolve names+icons from the
discovery catalog (fixes the broken portfolio icon) and are no longer
flagged spam at the source.

10.3.1 is the clean republish of 10.3.0 (which shipped a broken
@pioneer-platform/pioneer-caip: workspace:* that no external consumer could
install). Drop the direct pioneer-caip dep — it resolves transitively via
discovery's ^9.27.10. bun.lock is gitignored; release does a fresh resolve.

NOTE: re-validate the Windows MAX_PATH 260-assertion on the release build —
discovery's nested ethers/@ethersproject chain is the recent (v1.4.5)
long-path offender; PR #280's dedup should absorb it but confirm.

* chore(hdwallet): bump to 25db4467 (RUJI denom fix + EIP-712 eth-sig-util→eip-712)

Submodule now carries both fixes on fix/thorchain-any-denom (ff-safe
descendant of hdwallet master c5a4d79b): the THORChain bank-token denom
support and the EIP-712 struct-hash swap that drops @metamask/eth-sig-util
+ its nested @ethereumjs@4 tree (Windows MAX_PATH cleanup).

* chore(hdwallet): re-pin to 91a9e9d6 (EIP-712 commit amended: prettier lint fix)

Same two ff-safe commits (RUJI denom + EIP-712 swap); the EIP-712 commit was
amended to satisfy the hdwallet prettier/lint CI on the regression test. No
logic change. Old 25db4467 superseded.

* fix(swap): import pioneer-discovery chains via ./chains export (10.3.1 exports map)

pioneer-discovery 10.2.0+ added a strict package exports map; the raw deep
path @pioneer-platform/pioneer-discovery/lib/chains.json is no longer
resolvable (vite: Missing "./lib/chains.json" specifier), breaking the vault
build after the 10.3.1 bump. Use the exposed ./chains subpath (same
lib/chains.json, with types). Only deep import in the tree; the other three
discovery imports use the main entry.

* chore(device-protocol): pin fork master 98ca1e2 (ThorchainMsgSend.denom)

Bumps modules/device-protocol f2c3c005 -> 98ca1e2 (BitHighlander/device-protocol
origin/master). Adds the denom field to ThorchainMsgSend (proto commit c1dea44),
which hdwallet's send.setDenom() needs for TCY/RUJI/secured-asset MsgSend.

lib/ is a gitignored build artifact; 'make' regenerates it via 'npm run build'
(build:js + postprocess; the broken build:json is not in that path), so the
shipped lib gets setDenom from a clean checkout. Verified on-device: ruji.js 8/8
(rune/tcy/rujira/btc-btc/eth-usdc MsgSend + MsgDeposit + charset-guard reject).
v1.4.9 published as prerelease.
fix(hive): correct derivation path to SLIP-0048 (Phase 0) + onboarding plan
…300)

* feat(hive): sponsor-backed account onboarding + ETH anti-drain gate

Hive receive/onboarding panel (HiveAccountPanel) on the Hive asset page:
resolves the device's active key to an account via Pioneer, or runs the
in-app sponsor onboarding wizard (live username availability, device-derived
SLIP-0048 role keys, @keepkey-sponsored on-chain creation; keys never leave
the device).

Backend RPCs: hiveGetRoleKeys, hiveGetAccount, hiveUsernameAvailable,
hiveCreateAccount (device owner-signed account_create attestation -> Pioneer
sponsor endpoint). hiveSignTx/hiveSignAccountCreate route through emuSigningOp
on the emulator for the interactive confirm gate.

ETH anti-drain gate: hiveCreateAccount signs a fixed EIP-191 message bound to
username+ownerKey with the device ETH key (m/44'/60'/0'/0/0) and sends
ethAddress+ethSignature. The server requires the address to hold mainnet ETH
(one sponsored account per funded address) to keep the free service from being
drained. 403 surfaces a dedicated 'fund your ETH address' screen.

Bumps hdwallet 91a9e9d -> d51727e4 (master) for the Hive account-lifecycle
wallet methods (keepkey/hdwallet#50).

* fix(hive): address PR review — success poll, derive-error surfacing, username race

- P1: after a successful create, poll hiveGetAccount (backing off, 90s cap)
  and only hand off to the parent once the account resolves, instead of a
  single 4.5s refresh that could bounce a real success back to the wizard
  when the pubkey->account lookup lagged. Poll halts on unmount.
- P2: surface address-derivation failures instead of an infinite spinner —
  the panel now takes loading/deriveError/onRetryDerive; a null activeKey
  with an error (or no in-flight derive) shows an actionable Retry.
- P2: ignore stale username-availability responses (tag avail with its name +
  latest-name guard) so a late 'available' can't enable creation for a
  different, unchecked name. create() guard hardened to match.

* fix(hive): guard the View account button against the success bounce too

The auto-poll already waited for hiveGetAccount to resolve before handing
off, but the manual 'View account' button still called onCreated blind — a
click before Pioneer resolved the pubkey would refresh()->noAccount->wizard,
unmounting the celebration. Both paths now share a guarded resolveToAccount():
the button does the same lookup, keeps the success screen on noAccount, and
shows a 'still finalizing' hint instead of bouncing.
…K (+ #272 wrong-word test) (#306)

* feat(recovery): expose cipher-recovery character entry over REST + SDK + #272 test

Adds REST endpoints so an SDK/host can drive on-device cipher recovery (the engine
already had the machinery — sendCharacter/Delete/Done — wired only to the in-app UI):
  POST /system/recovery/character        {character}
  POST /system/recovery/character/delete
  POST /system/recovery/character/done
  GET  /system/recovery/state            -> { word_pos, character_pos, seq }
Mirrors /system/recovery/pin. engine-controller tracks the last CharacterRequest
(transport event 80) + a seq counter so a caller can sync sends with the device.

SDK: sdk.system.recovery.sendCharacter/sendCharacterDelete/sendCharacterDone/getRecoveryState.

Test tests/recovery/wrong-word.js validates firmware #272 (per-word BIP-39 validation):
drives recovery and enters a guaranteed-invalid word (5 identical ciphered chars ->
never a wordlist word, dodges auto-complete regardless of the unreadable scramble),
asserts recoverDevice() rejects with "Word not found". Note: lib/ is a build artifact
— run `npm run build` (or test:device) before the test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(recovery): address PR #306 review — namespace, lib, state reset, timeout

- SDK namespace: move sendCharacter/Delete/Done/getRecoveryState into a new
  sdk.system.recovery.* namespace (matches the test + docs; sendPin stays in
  device for back-compat). Fixes the test throwing on sdk.system.recovery.*.
- Stale lib: commit rebuilt lib/index.{js,d.ts}(+maps) so published main/types
  match src (this also catches up pre-existing un-built methods e.g. solana
  offchain / TIP-191 that were already in src but never built).
- State leak: reset lastCharacterRequest + characterRequestSeq + recoveryActive
  in clearWallet() (disconnect/re-pair) so /system/recovery/state can't return
  stale progress. Add an explicit  marker; recover-device wraps the call
  in try/finally so state is active only during entry. seq stays monotonic.
- Timeout: wipe/resetDevice/recoverDevice/loadDevice now use the SDK signing
  timeout (600s) instead of the 30s default, since they block on human confirm.

Follow-up (noted): bind /system/recovery/state to the initiating session/bearer
token if it becomes public API (it exposes only word/char position, no secrets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These were created in prior sessions but never committed:
- tests/evm-firmware/ (6): on-device clear-sign verifiers for firmware #255/#260/#261
  (eip1559 chainid/recover, sellToUniswap offset, thorchain router pin,
  transformERC20 clear-sign, uniswap liquidity recipient).
- tests/recovery/load-verify.js: wipe + loadDevice(random seed) + verify the device
  derives the same ETH address ethers does (seed-derivation check; distinct from the
  #272 cipher-recovery wrong-word.js already on develop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… chain (#307)

On a fresh wallet, picking a chain from the "Add a blockchain" grid drilled
into it but the Receive/Send/Swap action row was gated behind hasAnyBalance.
With no funds anywhere, hasAnyBalance is false, so the row never rendered and
new users could not press Receive — completely blocked from getting a deposit
address.

Ungate the drilled-chain action row from hasAnyBalance (the point of picking a
chain is to receive on it), and suppress the generic "pick a chain" welcome
empty-state while a chain is drilled so it doesn't sit above the action row.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BitHighlander and others added 28 commits June 30, 2026 19:05
* docs: firmware-release SOP — upstream-first dependency gating

Documents the rule that firmware releases pin UPSTREAM device-protocol +
python-keepkey masters, and nothing merges to fork develop until those proto/
test deps are merged upstream first (peer-review gated, days). Corrects the
Phase-5-last ordering in the 7.14.0 release plan, which had it backwards.

* docs: clarify mergability is in-order (sequential pipeline), not per-branch isolation
…n parse (#297)

build-windows-production.ps1 contained UTF-8 em-dashes (U+2014). The file has
no BOM, so PowerShell 5.1 reads it in the system ANSI codepage; on a non-UTF-8
codepage the em-dash bytes (E2 80 94) mis-decode and, inside the line 424
"build failed for proto-tx-builder ..." string, produce a stray quote that
closes the string early -> the whole script fails to parse and the Windows
build never starts. (Introduced with the proto-tx-builder dist build in #295.)

Replace every em-dash with ASCII "--". Comments and the one in-string use are
now codepage-independent.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…motion gate (#282)

v1.4.6 shipped as a prerelease validated only by local/static checks. Local
Orchard proof-verify can't distinguish a good tx from a doomed one (the NU6.2
incident proved only a node broadcast can), and the new fail-closed validation
could also false-abort a legitimate send. This checklist captures the real-device
send/shield/deshield + anti-bleed smoke that must pass before promoting v1.4.6
from prerelease to latest/stable.
#275)

Pins that the real-spend tree/witness layer is consensus-sound: a note in
a COMPLETED shard below other (insert()ed-root) shards + a frontier shard
produces a witness that recomputes the tree root. Uses the existing
assert_witness_recomputes_root helper (whose failure is exactly the chain's
'could not validate orchard proof').

This rules the witness/anchor OUT as the cause of that error on real z→z
spends — narrowing it to the Orchard proof's other public inputs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd-signing (#303)

The signing-approval overlay flags any tx the Vault's calldata decoder can't
read (source:'none') as needsBlindSigning, which forces AdvancedMode ON. But
AdvancedMode is the firmware's single blind-sign switch, so enabling it disables
the device's own blind-sign gate globally — re-opening the drain vector the
firmware router-pin fix (#261) closed, for any contract the firmware clear-signs
natively but the Vault decoder doesn't recognize (e.g. a THORChain plain
deposit forced the user to enable AdvancedMode).

Add the missing firmware-handled selectors to the local calldata decoder so they
decode (source:'local') and don't trip the gate:
  - THORChain deposit() 0x1fece7b4 (only depositWithExpiry was covered)
  - 0x sellToUniswap 0xd9627aa4
  - Uniswap addLiquidityETH 0xf305d719
The device stays authoritative — it re-checks router/offset/recipient and
rejects spoofed variants regardless of what the overlay displays.

Also add tests/evm-firmware/ SDK tests that drive the live Vault REST API to a
real device and verify the firmware EVM fixes via signer-recovery (#255/#260/
#261): EIP-1559/RLP pre-image, transformERC20 clear-sign, THORChain router pin,
sellToUniswap offset validation, addLiquidity recipient guard. Verified
on-device against firmware 7.15.0 (da02a7b7).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…298)

Two defects made the wallet look Bitcoin-only when a user added another
chain (reported by multiple users on v1.4.2 — 'the only thing I can click
is Get BTC address' after Add a blockchain → Dash):

1. Dashboard: the 'Add a blockchain' picker only drilled the dashboard
   viz (setDrilledChainId) and never opened the chosen chain's page, so
   the hardcoded 'Get BTC address' welcome CTA stayed the only obvious
   action. It now opens the picked chain's Receive page.

2. index.ts: btcGetAddress is shared by every UTXO chain, but cached the
   derived address under the literal 'bitcoin' key — so a Dash/LTC/DOGE
   address overwrote Bitcoin's cache and never persisted under its own
   chain. The cache key now resolves from params.coin.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WalletConnect pairing used a macOS-only one-shot `screencapture`
("Scan screen") that required Screen Recording TCC permission and
decoded a single static PNG — flaky when the send/address page's
live-camera scanner (QrScannerOverlay, jsqr at ~10fps with retry)
already worked reliably.

Point WalletConnect at the same QrScannerOverlay component: a "Scan
QR" button opens the live camera, validates the decoded string is a
`wc:` URI, and pairs. Drop the dead `wcScanScreen` backend RPC, its
schema entry, and the now-unused PNG-decode helper.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Sync button (zcashShieldedScan) only did an incremental scan — instant
and a no-op when already at chain tip, and it never re-checked the cached
FVK against the device. #274 wired the device-match into the balance and
send paths but NOT the manual Sync, so pressing Sync did no real work.

Now Sync resets the verified flag and runs ensureZcashDeviceMatch every
time: a forced device round-trip that purges the stale FVK + notes and
re-derives on mismatch. This makes Sync do real work AND lets the user
clear a stale/bled wallet from the UI without manually deleting
~/.keepkey/zcash_wallet.db.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(audit): track & recover UTXO-altcoin funds beyond account 0

Users report (e.g. LTC) that after updating, the audit finds funds but
shows 'Funds here, but KeepKey can't track this path in-app' with only a
support handoff. Cause: getBalances/getBalance derive only account 0 for
non-BTC UTXO chains, and the audit's canTrack gate excluded UTXO families
entirely — so discovered account-1+ funds were stranded.

Tracks them the device-ID-scoped way (NOT EVM's global-setting model):

- New addUtxoAccount RPC persists a discovered account's xpubs to the
  existing device-scoped cached_pubkeys table (PK device_id,chain_id,path),
  gated !isPassphraseWallet exactly like the BTC xpub cache. A hidden
  wallet's xpubs never touch disk and accounts never bleed across devices.
- getBalances (portfolio) and getBalance (send) now merge device-cached
  altcoin xpubs beyond account 0 (dedup by xpub), so recovered funds are
  both visible AND spendable.
- AuditDialog: trackLevel gains a utxo branch; canTrack includes utxo only
  for the standard wallet (hidden stays support-handoff, since the cache
  can't hold it). Copy is now honest: 'shows from now on'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audit): actually spend tracked account-1+ UTXO-altcoin funds

The audit "track" flow persisted account-1+ xpubs and merged them into the
balance-display RPCs, but the spend path (buildTx non-BTC UTXO branch) still
derived account-0 xpubs only. Result: funds beyond account 0 were shown and
offered by max-send but could not be spent (buildUtxoTx coin-selected only
account-0 UTXOs) — the "spendable" promise was false.

Two-part fix:
- addUtxoAccount now keys the cached row by the account-level BIP32 path
  instead of the xpub, so the account INDEX survives. The spend path needs it
  to rebuild per-input signing paths for account > 0 (utxo.ts rewrites the
  first 3 addressNList segments from the UTXO's source account path). Dedup is
  still one row per (scriptType, account) via the (device,chain,path) upsert;
  the xpub stays in its own column for the display merges.
- buildTx's non-BTC UTXO branch now merges the device-cached account-1+ xpubs
  into derivedXpubs/allXpubs, each carrying its parsed account path, so
  buildUtxoTx aggregates their UTXOs and signs them with the correct key.
  Device-scoped, gated !isPassphraseWallet, dedup by xpub.

Device smoke still required: track an account-1-funded LTC/DOGE/DASH, then
send consuming those UTXOs and confirm the signature recovers to the device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audit): harden UTXO spend to aggregate for any account>0 xpub

Defense-in-depth for the account-1 spend path: use multi-xpub aggregation
whenever any derived xpub is account > 0, not only when there are 2+ xpubs.
The single-xpub path ignores per-xpub accountPath, so a lone account-N xpub
(account-0 derivation returned nothing but a tracked account-1 row exists)
would sign with blockbook's account-0 path — a wrong key. Aggregation tags
each input with its source account path, guaranteeing account-N signs with
the account-N key. Not reachable in practice (a connected device derives
account 0 reliably), but this closes the one design dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(swap): aggregate tracked account-N UTXOs in NEAR Intents sendMax estimator

headlessSwapQuote's sendMax fee estimator derived only the default account-0
xpub for non-BTC UTXO chains, so a funded account 1 with an empty account 0
produced no UTXOs → estimateUtxoFee returned null → the net-amount re-quote was
skipped → the NEAR Intents sendMax deposit could under-deliver, even though
getBalances now surfaces (and buildTx now spends) those account-N funds.

Merge the device-cached account-1+ xpubs into estXpubs here too, mirroring the
buildTx merge: each carries its parsed account path, gated !isPassphraseWallet,
dedup by xpub. Aggregation kicks in only when a cached account-N xpub exists,
so the account-0-only path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#308)

hdwallet does not strip the EIP-1559 fee fields, so defaulting an absent
maxPriorityFeePerGas to '0x0' reaches the device as a literal [0x00] byte. The
firmware hashes that non-canonically (a zero RLP integer must be the empty string
0x80), so the signature is over a pre-image no standard encoder reproduces and the
tx recovers to the wrong signer / is unbroadcastable.

Root-caused on-device (fw 7.15.0): a zero-priority EIP-1559 tx recovered to a
random address (keepkey-sdk tests/evm-firmware/eip1559-recover.js). Immediate
unblock; the authoritative firmware fix (canonicalise zero RLP fields) is in
keepkey-firmware PRs to alpha + develop.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etach kickout) (#310)

getBalances derives every non-EVM chain's address on each refresh. Hive was
added ahead of the firmware that implements HiveGetPublicKey (msg 1600), so on
firmware without it the device replies FAILURE "Unknown message" and that
unrecognized round-trip drops the device off the USB bus — re-enumerating it
roughly every 2 minutes while idle and bouncing the user from the dashboard
back to the wallet picker.

- Probe once per device+firmware, cache the FAILURE_UnexpectedMessage miss, and
  skip that chain until the firmware version changes. Only the specific
  unknown-message failure is cached, so transient/device-busy errors still retry.
- Capture the capability key + device id BEFORE the derivation call: a failure
  can clear cachedFeatures, and recomputing in the catch would store a
  mismatched miss (unknown:?:chain), letting the next refresh re-send.
- Apply the same gate to the single-chain getBalance path (e.g. drilling into
  Hive to Receive) so it can't re-trigger the detach.
- Drop any stale cached balance for a chain when first marked unsupported, so a
  row cached under earlier firmware can't linger in the dashboard.

Verified on device (fw 7.15.0 candidate): Hive probed exactly once, skipped
thereafter, 0 USB detaches across the idle window (was ~1 every 2 min).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rd): always-show default chains, EVM-only Add chain (#311)

* fix(onboarding): reopen setup wizard when a completed device returns to needs_init

wizardComplete is only ever cleared by Dashboard (via onForceRefreshConsumed),
and Dashboard only mounts in the "ready" phase. If the wizard set
wizardComplete=true and the device then went to needs_init instead of ready
(wiped, or rebooted uninitialized after a firmware flash), Dashboard never
mounts, wizardComplete stays true, oobEnteredRef is forced false, and a
needs_init device falls through every phase branch to the default "splash" —
the wallet-picker — so the OOB wizard can never reopen.

Clear wizardComplete whenever the device is in a genuine setup state
(bootloader / needs_firmware / needs_init) so the wizard remounts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(dashboard): always show default chains; scope "Add chain" to custom EVMs

The sidebar hid empty-balance default chains behind an "Add a blockchain" grid
(ChainPickerDialog), while the custom-EVM AddChainDialog — the only thing that
actually needs adding — was unreachable: its sole trigger lived inside a dead
`{false && <SimpleGrid>}` card view.

- sidebarChains = sortedChains: every default chain is always shown (funded
  first), no per-chain balance gating. Removes hiddenChains + the session-only
  manuallyShown/showChainPicker state.
- The sidebar "+" button now opens AddChainDialog (custom EVM: browse Pioneer
  catalog or manual chainId+RPC), relabeled "Add EVM chain".
- Delete the orphaned ChainPickerDialog and the pre-existing dead
  `{false && <SimpleGrid>}` grid-card block (and its unused SimpleGrid import).

Net -224 lines in Dashboard.tsx. Custom-EVM backend (browseChains /
addCustomChain / getCustomChains, custom_chains table) is unchanged — this only
revives its UI entry point and drops the built-in chain-picker gating.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…312)

* fix(hive): surface real backend error on account-create failure

The account-create catch hardcoded 'device or network error', masking the
actual cause (hdwallet has no hiveSignAccountCreate binding, so the wallet
call throws 'is not a function'). This misattribution sent debugging toward
Pioneer/network. Surface e.message so the real reason is visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hive): gate ETH anti-drain gate off until Pioneer implements it

Vault signed an EIP-191 ETH gate and sent ethAddress/ethSignature, but
deployed Pioneer never built the server side — TSOA rejects the excess
fields (confirmed via prod logs), so account creation always 400'd and the
user got a pointless ETH confirm on-device first.

Add in-code flag HIVE_ETH_GATE=false: skip the ETH signing + omit the
fields so the request matches deployed Pioneer. Also log Pioneer's exact
reject reason on any non-200. Flip back on once Pioneer ships the gate
(docs/handoff-pioneer-hive-eth-gate.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Flip HIVE_ETH_GATE=true — the vault signs the EIP-191 gate and sends
ethAddress/ethSignature again. Releasing with the gate on.

HARD DEPENDENCY: requires Pioneer's server-side ETH gate deployed (accept +
verify the fields) AND HIVE_ONBOARDING_ENABLED=true, or create-account 400s
on the excess fields. See docs/handoff-pioneer-hive-eth-gate.md. Re-enables
what #312 gated off.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent failures, stale shadows) (#314)

Root cause of "sent HIVE, pressed refresh on asset page, nothing happened":
the single-chain getBalance derive drops result.publicKey, so hive
(hiveGetPublicKey returns { publicKey }, no address) threw "Could not
derive address for Hive" before ever reaching Pioneer — and AssetPage
swallowed the rejection into console.warn. The bulk getBalances path has
the fallback, which is why dashboard refresh-all worked. Audit verified
the forceRefresh flag itself is sound end-to-end (client wire →
?forceRefresh=true → Pioneer cache bypass, hive included).

- bun getBalance: add `|| result?.publicKey` fallback (mirrors getBalances)
- bun getBalance: write back with force=true — a Pioneer-confirmed result
  (incl. genuine zeros / unpriced balances) must overwrite stale cache,
  same rule as getBalances' confirmedChainIds
- AssetPage: delete refreshedBalance — it permanently shadowed newer
  balance-prop data (pushes, bulk refreshes, post-send resyncs) for the
  rest of the mount; the backend already pushes 'balance-updated' which
  flows back via the prop
- AssetPage: surface refresh failures (rose chip + error tooltip) instead
  of silently stopping the spinner under a green "Synced" chip; 90s rpc
  timeout to cover the backend's 60s Pioneer budget (was 30s default)
- AssetPage: delete the scoped tx-push subscription — duplicate of the
  always-mounted App.tsx listener (double forced fetch per push, and its
  chain-matching was bypassed by 'confirmed' pushes); drop the redundant
  BTC refreshBtcAccounts round-trip ('btc-accounts-update' push covers it)
- AssetPage: hide the refresh button in watch-only (device required),
  format the mobile balance row
- HiveAccountPanel: refresh button now re-fetches HIVE/HBD/HP via a
  refreshNonce prop (was fetch-once-per-key, unreachable by any refresh)
- Dashboard: key AssetPage by chain id so chain switches remount cleanly
- Soft-path hygiene: EVM autoDiscover runs once per session with a soft
  Pioneer read (was: device re-derives + forced scans on every
  getBalances, incl. page loads); address-book seeding no longer forces
- i18n: refreshFailed key, all 14 locales
- docs: pioneer-side handoff (hive push coverage, degraded fetchedAt
  rewrite, hive adapter hardening)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n paths; repair the push pipeline (#316)

getBalance was a ~470-line hand-mirrored fork of getBalances and every one
of the six drift bugs the refresh audit found lived in a mirrored leaf.
Extract the leaves into shared module helpers so the paths cannot diverge
again (locality kept — orchestration stays per-handler):

- deriveChainAddress(): param shape, ripple quirk, { address } vs
  { publicKey } result shapes (hive), unknown-message capability recording
  + stale-cache-row drop — used by both handlers (getBalance previously
  never recorded misses, so it kept re-sending rejected messages — the
  #310 USB-detach class)
- isTokenEntry / parseTokenEntry / CONTRACT_CAIP_RE: token classification
  + parsing (getBalance and refreshWatchOnlyBalances regain the
  denom|bank contract match — TCY/RUJI — and the caip-prefix networkId
  fallback)
- dedupeTokensByCaip: the byte-identical dedup loop existed 5 times
- mapServerDefiPosition: the DeFi mapping existed 3 times
- fetchShieldedZecToken: zcash shielded zZEC merge — getBalance now
  attaches it too (a single-chain zcash refresh used to wipe the cached
  shielded sub-row via its tokens_json overwrite)

getBalance also now:
- honors response meta: degraded Pioneer data surfaces on the warning
  banner and is treated as UNCONFIRMED (no force cache overwrite — a
  degraded zero must not clobber a good cached balance)
- applies the same firmware/feature-flag chain gates as getBalances
- takes an optional forceRefresh param (default TRUE — user refresh /
  post-send / tx pushes must bypass Pioneer's cache)

Push pipeline repair (socket leg):
- Parse JSON-stringified payloads and resolve the chain from
  caip/networkId (the server's 'chain' field is symbol-ish, so genuine
  watchtower 'transaction:incoming' events were mostly dropped); map the
  server's payload types into the schema union ('confirmation_update' →
  'confirmed') so the incoming toast works from this leg.
- Deliberately consume ONLY 'transaction:incoming'. Pioneer's
  'balance:update'/'balance:cache:update' fire from INSIDE its
  GetPortfolioBalances controller per request, to the requester's own
  socket — consuming them creates a self-sustaining refresh feedback
  loop (caught in adversarial review; documented in
  docs/handoff-pioneer-hive-push-refresh.md §7 for the server-side fix).
- Dedup pushes by txid across the SSE and socket legs (one deposit no
  longer double-toasts and double-fetches); merge pending debounced
  payloads so a confirmation update can't eat an incoming-payment toast.
- Debounce map hoisted to module scope and cleared on disconnect (pending
  timers used to fire getBalance at an unplugged device); SSE tx:confirmed
  forwards now debounced per network (they re-fire per confirmation, each
  costing a forced fetch).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
broadcastTx requires signedTx.hiveBuildResult to assemble the Hive
broadcast body (ref block, expiration, from/to, amount), but the
hiveSignTx RPC handler only returned {signature, serializedTx},
dropping it. Every Hive send failed at broadcast with "Hive broadcast
requires hiveBuildResult in signedTx". Re-attach it from params,
matching the existing tronSignTx/tonSignTx handlers which already
pass their own build-result fields through the same way.
Resyncing the balance immediately after broadcast re-reads the
balance server before it has indexed the just-broadcast tx, so the
resync briefly overwrites the UI with the pre-send balance. Delay the
resync so it lands after the indexer has caught up, and snapshot the
chainId at broadcast time so the resync stays gated to the network the
tx was actually sent from even if the form has since moved on.
…art (#319)

`cmd /c start` re-parses its command line with cmd.exe's own quoting
rules, which don't understand backslash-escaped quotes the way
Bun.spawn's Win32 argv-escaping does. The two layers collide and mangle
the URL into a stray leading backslash ("Windows cannot find
'\https://keepkey.com/update?os=windows'"), breaking the update
download link and general external-link opening on Windows.

Switch to `rundll32 url.dll,FileProtocolHandler <url>`, which takes the
URL as a normal argv element with no shell re-parsing, so both the `&`
in query strings and Bun's escaping work correctly.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…not the ceiling (#320)

A USDT send failed on-chain as "Failed — Out of Energy"
(c105241aee4b1c77cee43e24717741dffc74ed91b35475ce4b5153497b463baf),
burning the full 30 TRX fee_limit with no transfer. The 30 TRX cap is
below the real cost of a transfer to a zero-USDT recipient once TRON's
dynamic-energy penalty on the USDT contract kicks in (~130k energy base
+ penalty up to 3.4x, >30 TRX at peak).

- fee_limit → 100 TRX (ceiling only; actual consumption is charged;
  covers the max-penalty worst case; matches TronLink's USDT default)
- displayed fee is now a triggerconstantcontract simulation
  ((energy_used + energy_penalty) × getEnergyFee) instead of the
  ceiling, falling back to the ceiling if estimation fails

Verified against live TronGrid with the failed tx's exact parameters:
energy_used=130285, energy_penalty=100635, getEnergyFee=100 sun
→ ~23 TRX real cost today, confirming 30 was too low and 100 is safe.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
XrpSignRequest used .strip() but never declared tx or lastLedgerSequence
in its shape, so both were deleted from the request body before reaching
hdwallet.rippleSignTx, which reads msg.tx.value.fee/msg and
msg.lastLedgerSequence. Every REST/SDK-driven XRP sign threw
"undefined is not an object (evaluating 'msg.tx.value')". Swagger and
hdwallet both require the fields; the schema was the outlier. The in-app
send path signs over IPC and bypasses this schema, so it shipped uncaught.

Verified end-to-end on a real device: address derive -> build -> on-device
confirm -> valid signature + correct txid/fields.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…address (#321)

Litecoin "stranded funds" end-to-end fix + recovery tooling:

- utxoAccountScriptPaths (chain-scan): per-account xpub set now includes the
  chain's own receive convention AND the LTC legacy p2wpkh-on-BIP44 branch,
  so pre-1.4.10 addresses stay visible and spendable everywhere (getBalances,
  buildTx, audit account scans, addUtxoAccount). LTC default path -> BIP84.
- AssetPage: LTC script-type picker (44/49/84) for receive addresses; UTXO
  altcoin multi-account selector (UtxoAccountSelector) backed by the pubkey
  cache; Synced-at badge only stamps data it can vouch for.
- Audit "Scan uncommon paths" grid for LTC (p2wpkh-on-44 / 44-p2pkh / 84-
  p2wpkh) via auditScanPaths + scriptType.
- NEW auditSweepPath RPC + AuditSweepButton: funded uncommon-path finds get a
  "Sweep to main wallet" button right in the audit flow — signs with the
  exact found path (no xpub/tracking machinery, nothing persisted), sends
  max-minus-fee to the chain's standard receive address. Dry-run quote, then
  sign + broadcast. Re-derives the address on the CURRENTLY connected device
  before building (device-swap guard) and re-checks the wallet handle before
  signing. Only uncommon scheme rows offer it — the standard receive branch
  is the destination, never a source (backend also refuses dest==source).
  sweep-engine generalized to take coin/networkId (BTC callers unchanged);
  fee-rate fetch falls back GetFeeRateByNetwork -> GetFeeRate like the send
  path (was silently defaulting to 5 sat/vB).
- Patient audit balance checks: 5 attempts, 30s apart, before any row shows
  "couldn't verify" — degraded-empty (200-but-failed upstream) retries the
  same way instead of failing instantly. UI scan timeouts raised to 15 min.

Device-verified on-chain (LTC): swept 0.00635104 LTC from uncommon
m/44'/2'/0'/0/0 (p2wpkh) to the standard BIP84 receive address, txid
5d4c29093bf6f8ff61aae603c7e2a906cdbf9d3e93190c43cc400fa1fece83fb.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…cing to 0 (#322)

* fix(portfolio): sum multi-xpub UTXO chains per pubkey in bulk getBalances

The bulk dashboard refresh matched non-BTC UTXO natives by caip, but LTC
sends 4+ xpubs (p2pkh/p2sh-p2wpkh/p2wpkh + legacy 44'-p2wpkh + tracked
accounts) that all share one caip, and Pioneer returns one row PER pubkey.
The caip find() grabbed the chain's first row — typically the empty legacy
Ltub — so the dashboard wrote LTC=0 while the single-chain getBalance path
(which sums by pubkey) wrote the real total, making the displayed balance
bounce between 0 and correct on alternate refreshes.

Mirror the BTC aggregation: match each entry strictly by its own pubkey,
sum across the chain's xpubs, and push one row per chain. If Pioneer
returned no row for any of a multi-xpub chain's pubkeys, drop the chain
from confirmedChainIds so an unvalidated 0 never overwrites the cache.

Verified against live Pioneer: 4 LTC xpubs → per-pubkey rows summing to
0.02225830, matching the single-chain path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(portfolio): confirm multi-xpub chain only when every pubkey chunk succeeded

Review finding: if one LTC xpub chunk succeeds while a tracked-account
xpub chunk fails, agg.matched stayed true and the partial (lower) sum was
force-written as confirmed. Track per-chain allOk from failedPubkeySetForDb
— mirroring the BTC every-pubkey rule — so partial aggregates go through
the guarded upsert and never overwrite a good cached balance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…signed (#323)

After flashing custom firmware in the setup wizard, the device reboots
into the new firmware (e.g. test 7.15.0, needs_init) but the wizard
stays stranded on the Firmware step, offering a downgrade to the
release channel (7.15.0 -> 7.14.1) and polling for bootloader entry
forever.

Root cause: the custom-flash completion handler set
rebootPhase='firmware-rebooting' but not firmwareJustFlashed (the
official-update path sets both). The reboot-advance effect then fired
on stale cached bootloader features, cleared rebootPhase before its
s==='bootloader' early-return, and nothing was left to route the wizard
once the device came back.

Fixes:
- set firmwareJustFlashed on custom-flash completion, so the existing
  post-flash advance effect routes to init/complete like the official path
- auto-skip the Firmware step when needsFirmwareUpdate is false and the
  device is not in bootloader (mirrors the bootloader auto-skip) — never
  offer a downgrade when running firmware is current or newer
- show a SIGNED/UNSIGNED badge next to CURRENT version on the Firmware
  step (deviceStatus.firmwareVerified: running version known to the
  release manifest)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ight (#324)

* fix(ui): restore LOW GAS warning for all chain families + swap pre-flight

The dashboard LOW GAS badge (added 2a767ba, Apr 2026) went dark in the
May UI revamp (39d1ca5 wrapped the old card grid in {false && ...}) and
the dead block was deleted in #311 — the sidebar rows that replaced the
cards never got the badge. It was also EVM-only.

Restore it on the sidebar chain rows for EVERY family: tokens stranded
on a chain (>$1) with dust native (<$1) get a red gas-pump + LOW GAS
tag (TRC-20 needs TRX, ERC-20 needs ETH, SPL needs SOL, ...).

SwapDialog gets the messaging it never had: swapping a TOKEN from a
dust-native account signs on-device and then dies at broadcast (TRON:
"Account resource insufficient error"). Warn in the FROM panel before
the user signs. Warning, not a gate — staked resources (TRON energy)
can make fees free with zero native balance.

SendForm's existing token-send banner already covers all families and
is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(swap): judge low-gas on the selected EVM signing address, not the aggregate

Review: the chain-aggregate nativeBalanceUsd can hide a dust-native
signing address behind a funded sibling — the selected address is what
actually pays gas at broadcast. Mirror fromBalance's selected-address
lookup, falling back to the aggregate for non-EVM/untracked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The QR overlay (WalletConnect pairing) had camera + image-file. This adds
"scan my screen": Bun minimizes the window, screenshots every display with
the OS-native tool, and the webview decodes the PNGs with the existing jsQR
path. Native because WKWebView auto-denies getDisplayMedia once the app
implements the media-capture delegate Electrobun uses for the camera.

macOS permissions (the pain point) — see docs/scan-screen-permissions.md:
- Screen Recording is pure TCC; no codesign entitlement exists.
- CGPreflight/CGRequestScreenCaptureAccess via bun:ffi; denied requests
  deep-link the exact System Settings pane. Relaunch needed after granting.
- NSScreenCaptureUsageDescription injected by patch-electrobun.sh (covers
  dev + prod bundles, same mechanism as the camera string).
- Dev builds: TCC keys the grant to the responsible process, i.e. your
  terminal/IDE — grant Screen Recording there once; prod grants stick to
  the Developer ID-signed bundle.
- Unauthorized screencapture fails loudly (verified on macOS 26), so a
  permission gap can't masquerade as "no QR found".

Windows: PowerShell CopyFromScreen per monitor, no permission gate.
Linux: grim / gnome-screenshot / spectacle / import, first available.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… 7.15+) (#326)

* fix(clearsign): send full unsigned tx to /descriptors/sign, decode base64 blob, gate on VERIFIED

The committed pioneer clear-sign path fetched the signed blob with only
{chainId, contractAddress, data} and attached it as a hex string. Two bugs
surfaced on-device (rc3, R715rc3):

1. hdwallet's ethSignTx arrayify()s a STRING signedPayload as hex, but
   pioneer emits base64 → "invalid hexadecimal string" thrown before the
   device renders anything (looked like "nothing happens after approve").
   Fix: decode base64 → Uint8Array so hdwallet takes the bytes branch.

2. The blob's tx_hash binds the full sighash (nonce/gas/value/fees), so a
   3-field request can never produce a blob rc3 accepts. Fix: thread the
   real unsigned-tx fields (mirroring the ethSignTx msg construction exactly)
   into the /descriptors/sign request.

Also gate attach on classification === 'VERIFIED' — an OPAQUE/UNKNOWN blob
doesn't enable clear-sign (rc3 fail-closes) and attaching it would mask the
honest raw-hex/AdvancedMode path. Adds insightClassification to the type.

Device-verified: base64 fix lets the metadata reach the device and return an
ack; remaining MALFORMED verdict is a pioneer-insight serializer / firmware
format issue, tracked separately (not a vault bug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(clearsign): gate EVM insight behind EVM_INSIGHT flag (default off, fw 7.15+)

The Pioneer signed-blob clear-sign path (blob fetch + attach to EthereumSignTx)
was unconditionally active. Gate it behind a new build-time flag EVM_INSIGHT
(default false) AND the connected device's firmware >= 7.15.0, checked at both
chokepoints:

- blob FETCH: the txFields that trigger fetchPioneerSignedBlob (rest-api.ts)
- blob ATTACH: msg.txMetadata, incl. the caller-provided branch — this reads the
  blob straight from the request body, so it must be gated independently or a
  caller could inject a blob and force clear-sign while disabled.

Local calldata decode + the blind-sign warning stay live on all firmware. New
shared/flags.ts is importable by both bun/ backend and mainview/ frontend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(clearsign): summarize Uint8Array signedPayload in the payload log

JSON.stringify(msg, null, 2) serializes a Uint8Array as a per-byte index
map — with a Pioneer blob attached that's hundreds of junk lines per
/eth/sign-transaction. Replacer collapses it to Uint8Array(<len>).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BitHighlander BitHighlander requested a review from pastaghost as a code owner July 3, 2026 05:44
@BitHighlander BitHighlander merged commit f01b156 into master Jul 3, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant